// app/api/files/[...path]/route.ts // /nas_evcp 경로에서 파일을 서빙하는 API (다운로드 강제 기능 추가) import { NextRequest, NextResponse } from "next/server"; import { promises as fs } from "fs"; import path from "path"; const nasPath = process.env.NAS_PATH || "/evcp_nas" // MIME 타입 매핑 const getMimeType = (filePath: string): string => { const ext = path.extname(filePath).toLowerCase(); const mimeTypes: Record = { '.pdf': 'application/pdf', '.doc': 'application/msword', '.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', '.xls': 'application/vnd.ms-excel', '.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.png': 'image/png', '.gif': 'image/gif', '.txt': 'text/plain', '.zip': 'application/zip', }; return mimeTypes[ext] || 'application/octet-stream'; }; // 보안: 허용된 디렉토리 체크 const isAllowedPath = (requestedPath: string): boolean => { const allowedPaths = [ 'basicContract', 'basicContract/template', 'basicContract/signed', 'vendorFormReportSample', 'vendorFormData', 'uploads', 'tech-sales', 'techsales-rfq', 'tech-vendors', 'vendor-investigation', 'vendor-responses', 'vendor-evaluation', 'evaluation-attachments', 'vendor-attachments', ]; return allowedPaths.some(allowed => requestedPath.startsWith(allowed) || requestedPath === allowed ); }; export async function GET( request: NextRequest, { params }: { params: { path: string[] } } ) { try { // 요청된 파일 경로 구성 const requestedPath = params.path.join('/'); console.log(`📂 파일 요청: ${requestedPath}`); // ✅ 다운로드 강제 여부 확인 const url = new URL(request.url); const forceDownload = url.searchParams.get('download') === 'true'; console.log(`📥 다운로드 강제 모드: ${forceDownload}`); // 보안 체크: 허용된 경로인지 확인 if (!isAllowedPath(requestedPath)) { console.log(`❌ 허용되지 않은 경로: ${requestedPath}`); return new NextResponse('Forbidden', { status: 403 }); } // 경로 트래버설 공격 방지 if (requestedPath.includes('..') || requestedPath.includes('~')) { console.log(`❌ 위험한 경로 패턴: ${requestedPath}`); return new NextResponse('Bad Request', { status: 400 }); } // 환경에 따른 파일 경로 설정 let filePath: string; if (process.env.NODE_ENV === 'production') { // ✅ 프로덕션: NAS 경로 사용 filePath = path.join(nasPath, requestedPath); } else { // 개발: public 폴더 filePath = path.join(process.cwd(), 'public', requestedPath); } console.log(`📁 실제 파일 경로: ${filePath}`); // 파일 존재 여부 확인 try { await fs.access(filePath); } catch { console.log(`❌ 파일 없음: ${filePath}`); return new NextResponse('File not found', { status: 404 }); } // 파일 통계 정보 가져오기 const stats = await fs.stat(filePath); if (!stats.isFile()) { console.log(`❌ 파일이 아님: ${filePath}`); return new NextResponse('Not a file', { status: 400 }); } // 파일 읽기 const fileBuffer = await fs.readFile(filePath); // MIME 타입 결정 const mimeType = getMimeType(filePath); const fileName = path.basename(filePath); console.log(`✅ 파일 서빙 성공: ${fileName} (${stats.size} bytes)`); // ✅ Content-Disposition 헤더 결정 const contentDisposition = forceDownload ? `attachment; filename="${fileName}"` // 강제 다운로드 : `inline; filename="${fileName}"`; // 브라우저에서 열기 // Range 요청 처리 (큰 파일의 부분 다운로드 지원) const range = request.headers.get('range'); if (range) { const parts = range.replace(/bytes=/, "").split("-"); const start = parseInt(parts[0], 10); const end = parts[1] ? parseInt(parts[1], 10) : stats.size - 1; const chunksize = (end - start) + 1; const chunk = fileBuffer.slice(start, end + 1); return new NextResponse(chunk, { status: 206, headers: { 'Content-Range': `bytes ${start}-${end}/${stats.size}`, 'Accept-Ranges': 'bytes', 'Content-Length': chunksize.toString(), 'Content-Type': mimeType, 'Content-Disposition': contentDisposition, // ✅ 다운로드 모드 적용 }, }); } // 일반 파일 응답 return new NextResponse(fileBuffer, { headers: { 'Content-Type': mimeType, 'Content-Length': stats.size.toString(), 'Content-Disposition': contentDisposition, // ✅ 다운로드 모드 적용 'Cache-Control': 'public, max-age=31536000', // 1년 캐시 'ETag': `"${stats.mtime.getTime()}-${stats.size}"`, // ✅ 추가 보안 헤더 'X-Content-Type-Options': 'nosniff', }, }); } catch (error) { console.error('❌ 파일 서빙 오류:', error); return new NextResponse('Internal Server Error', { status: 500 }); } } // HEAD 요청 지원 (파일 정보만 확인) export async function HEAD( request: NextRequest, { params }: { params: { path: string[] } } ) { try { const requestedPath = params.path.join('/'); // ✅ HEAD 요청에서도 다운로드 강제 여부 확인 const url = new URL(request.url); const forceDownload = url.searchParams.get('download') === 'true'; if (!isAllowedPath(requestedPath)) { return new NextResponse(null, { status: 403 }); } if (requestedPath.includes('..') || requestedPath.includes('~')) { return new NextResponse(null, { status: 400 }); } let filePath: string; if (process.env.NODE_ENV === 'production') { filePath = path.join(nasPath, requestedPath); } else { filePath = path.join(process.cwd(), 'public', requestedPath); } try { const stats = await fs.stat(filePath); if (!stats.isFile()) { return new NextResponse(null, { status: 400 }); } const mimeType = getMimeType(filePath); const fileName = path.basename(filePath); // ✅ HEAD 요청에서도 Content-Disposition 헤더 적용 const contentDisposition = forceDownload ? `attachment; filename="${fileName}"` // 강제 다운로드 : `inline; filename="${fileName}"`; // 브라우저에서 열기 return new NextResponse(null, { headers: { 'Content-Type': mimeType, 'Content-Length': stats.size.toString(), 'Content-Disposition': contentDisposition, // ✅ 다운로드 모드 적용 'Last-Modified': stats.mtime.toUTCString(), 'ETag': `"${stats.mtime.getTime()}-${stats.size}"`, 'X-Content-Type-Options': 'nosniff', }, }); } catch { return new NextResponse(null, { status: 404 }); } } catch (error) { console.error('File HEAD error:', error); return new NextResponse(null, { status: 500 }); } }